Skip to content

feat(indexer): add Prometheus /metrics endpoint with indexer and transfer counters - #175

Merged
Miracle656 merged 1 commit into
Miracle656:mainfrom
royalTreasure:feat/prometheus-metrics
Sep 1, 2026
Merged

feat(indexer): add Prometheus /metrics endpoint with indexer and transfer counters#175
Miracle656 merged 1 commit into
Miracle656:mainfrom
royalTreasure:feat/prometheus-metrics

Conversation

@royalTreasure

Copy link
Copy Markdown

Closes #39

Summary

Wraith runs as a persistent background service with no operational metrics — a stalled indexer or a degrading RPC endpoint was visible only in the logs. This adds a Prometheus scrape endpoint and instruments the indexer, the RPC retry path, and the hot database operations.

Metrics

New src/metrics.ts, using prom-client:

Metric Type Labels
ledgers_indexed_total counter network
transfers_stored_total counter network, type (fungible / nft)
rpc_errors_total counter outcome (retry / exhausted)
last_indexed_ledger gauge network
db_query_duration_seconds histogram operation

Standard process_* / nodejs_* collectors are registered alongside them.

Two details worth calling out:

  • The ledger counter takes the per-poll delta, not the absolute sequence. A process resuming from its DB cursor would otherwise report several million ledgers indexed in one second on every restart.
  • rpc_errors_total counts attempts, not calls. withRetry hides transient failures from its callers by design, so counting only calls that exhausted their retries would read zero right up until the indexer falls over. outcome keeps the two readings separable.

Everything registers into a module-local Registry rather than prom-client's global default — the global is process-wide state shared with any dependency that also uses prom-client, and it cannot be cleared between tests without clobbering theirs.

Endpoint

GET /metrics returns the exposition format with prom-client's content type. It reads in-process counters only — no DB, no RPC — so it keeps answering while the subsystems it reports on are down, which is the point of having it. For the same reason it is exempt from:

  • the API rate limiter (a scrape endpoint that starts 429ing goes blind exactly when load is high enough to matter), and
  • the stale-read middleware's RPC health probe (which would add a network round-trip to every scrape and report nothing useful).

Registered in the OpenAPI document (src/openapi/build.ts, openapi.json regenerated via npm run docs:openapi).

Instrumentation

  • src/indexer.ts — ledger progress and stored-row counts on both the single-poll and the parallel (INGEST_WORKERS > 1) paths, including the empty-batch case where the cursor still advances.
  • src/rpc.tswithRetry records each failed attempt.
  • src/db.tsobserveDbQuery wraps upsertTransfers, upsertNftTransfers, getLastIndexedLedger, setLastIndexedLedger, and queryTransfers. Failures are timed too: a query that runs for eight seconds and then throws is exactly the one worth seeing on a latency graph.

/status

Now reports last_indexed_ledger alongside the existing lastIndexedLedger, matching the gauge's name. It is an additive alias — the camelCase field is untouched and both always carry the same value.

Tests

src/__tests__/metrics.test.ts (8 tests): valid exposition format and content type, all five custom metrics present with the correct # TYPE, recorded samples rendered with their labels, the endpoint still serving 200 while DB and RPC both reject, observeDbQuery timing both the success and the throwing path (and rethrowing), and /status carrying both ledger field names.

Verification

  • npm test — 298 passed across 27 suites (was 290/26); coverage thresholds still met.
  • npx tsc --noEmit — clean.
  • npm run docs:openapi — regenerated, diff is the /metrics path only.

…sfer counters

Wraith runs as a persistent background service with no operational metrics: a
stalled indexer or a degrading RPC endpoint was visible only in the logs.

New src/metrics.ts registers five custom metrics into a module-local registry
(not prom-client's process-global default, which is shared state no test can
clear safely) alongside the standard process/Node collectors:

  ledgers_indexed_total      counter   {network}
  transfers_stored_total     counter   {network, type}
  rpc_errors_total           counter   {outcome}
  last_indexed_ledger        gauge     {network}
  db_query_duration_seconds  histogram {operation}

The ledger counter takes the per-poll delta rather than the absolute sequence,
so a process resuming from a DB cursor does not report millions of ledgers
indexed in one second on every restart. rpc_errors_total counts attempts, not
calls: withRetry hides transient failures by design, so per-call counting would
read zero right up until the indexer falls over.

GET /metrics reads in-process counters only — no DB, no RPC — so it keeps
answering while the subsystems it reports on are down, and it is exempt from
both the rate limiter and the stale-read RPC probe for the same reason.

/status gains last_indexed_ledger as a snake_case alias of lastIndexedLedger,
matching the gauge name; the existing field is untouched.
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@royalTreasure Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved and merging. This is the best-instrumented PR in the wave — it reads like it was written by someone who has been paged before.

Three decisions I want to call out, because each one is the non-obvious choice and each is right:

The ledger counter takes the delta, not the sequence. A resumed process starts from wherever the DB cursor left it. Feeding the absolute sequence into a counter would report several million ledgers indexed in one second on every restart, which poisons rate() and makes the one alert you actually want — a flat rate meaning the loop has stalled — unusable. Guarding on advanced > 0 also means a backwards cursor contributes nothing rather than a negative.

rpc_errors_total counts attempts, not calls. This is the subtle one. withRetry exists to hide transient failures from callers, so a per-call counter reads a clean zero right up until the indexer falls over — the metric would be silent for exactly the window you needed it. Counting attempts, split retry / exhausted, surfaces a degrading endpoint while it is still succeeding. That distinction is the whole value of the metric.

/metrics is exempt from both the rate limiter and the stale-read RPC probe, and touches neither DB nor RPC. A scrape endpoint that 429s under load goes blind exactly when the graphs matter, and one that makes a network round-trip per scrape reports on an outage by participating in it. The test does not depend on the database or RPC being up pins this by failing both mocks and asserting 200 — that is the right test to have written.

Also correct: a module-local Registry instead of prom-client's process-global default, and timing failed queries in observeDbQuery's finally — the eight-second query that then throws is the one worth seeing, and dropping it would leave a histogram that only describes the healthy path.

Verified locally on a merge with main: tsc --noEmit clean, full suite 299/299.

One follow-up, not a blocker: npm flags prom-client as deprecated in favour of @prometheus-io/client. 15.1.3 is stable and universally used, so this is fine to ship — I'll file a note to track the rename.

Thanks — this raised the bar.

@Miracle656
Miracle656 merged commit 87231e0 into Miracle656:main Sep 1, 2026
1 check passed
Miracle656 added a commit to royalTreasure/wraith that referenced this pull request Sep 1, 2026
Both conflicts were additive unions:
- src/api.ts: Miracle656#175's metrics import beside Miracle656#163's network middleware import.
- README.md: Miracle656#175's /metrics section beside Miracle656#163's /readyz section, with
  both /status notes kept under the /status example.

openapi.json regenerates identically from src/openapi/build.ts after the
merge, so the committed document is not stale.
Miracle656 added a commit to K1NGD4VID/wraith that referenced this pull request Sep 1, 2026
Derived per-token balances for an address, summing what it received and
subtracting what it sent across the indexed history.

Rebased onto main, which has moved a long way since this branch: the token
cache landed via Miracle656#46 and the metrics module via Miracle656#175, so those parts of this
PR are dropped as duplicates and what remains is the balance endpoint itself.

Three fixes to the query on the way in:

- The table reference was unqualified, `FROM "TokenTransfer"`. Every other raw
  query in db.ts uses `"wraith"."TokenTransfer"`, because the models declare
  @@Schema("wraith") — unqualified it resolves only if search_path happens to
  include the schema, so it would work locally and fail on a deployment that
  sets search_path differently.

- No network predicate. Summing both chains' transfers for one address gives a
  number that corresponds to no balance anywhere. Now takes the network and
  filters on it, with the route reading it from the selector so an unknown
  network 400s instead of silently answering for the default.

- The metrics timer was started and stopped around the query but not in a
  finally, so a throw leaked it. Uses observeDbQuery, which times failures
  too — a query that takes eight seconds and then fails is the one worth
  seeing.

Mounted on the existing accounts router rather than a second one, so it sits
beside /summary and /transfers and inherits the network middleware.

The response keeps this PR's honesty about what the number is — a sum over the
indexed window, not an on-chain read — and returns both the raw stroop amount
and the display string, so a consumer doing arithmetic does not have to parse
the decimal back and guess the scale.

tsc clean; full suite 402 passed.
@Miracle656 Miracle656 mentioned this pull request Sep 1, 2026
11 tasks
Miracle656 added a commit that referenced this pull request Sep 1, 2026
* feat: implement tiered token metadata caching with Prisma persistence and RPC fallback

* feat: implement Prometheus metrics collection with registry and endpoint testing

* feat: implement accounts balance route with ledger-derived token balances

* chore: add vitest as devDependency for test:integration

* fix: exclude broken upstream tests from jest (opa, integration)

* Add GET /accounts/:address/balance, network-scoped and schema-qualified

Derived per-token balances for an address, summing what it received and
subtracting what it sent across the indexed history.

Rebased onto main, which has moved a long way since this branch: the token
cache landed via #46 and the metrics module via #175, so those parts of this
PR are dropped as duplicates and what remains is the balance endpoint itself.

Three fixes to the query on the way in:

- The table reference was unqualified, `FROM "TokenTransfer"`. Every other raw
  query in db.ts uses `"wraith"."TokenTransfer"`, because the models declare
  @@Schema("wraith") — unqualified it resolves only if search_path happens to
  include the schema, so it would work locally and fail on a deployment that
  sets search_path differently.

- No network predicate. Summing both chains' transfers for one address gives a
  number that corresponds to no balance anywhere. Now takes the network and
  filters on it, with the route reading it from the selector so an unknown
  network 400s instead of silently answering for the default.

- The metrics timer was started and stopped around the query but not in a
  finally, so a throw leaked it. Uses observeDbQuery, which times failures
  too — a query that takes eight seconds and then fails is the one worth
  seeing.

Mounted on the existing accounts router rather than a second one, so it sits
beside /summary and /transfers and inherits the network middleware.

The response keeps this PR's honesty about what the number is — a sum over the
indexed window, not an on-chain read — and returns both the raw stroop amount
and the display string, so a consumer doing arithmetic does not have to parse
the decimal back and guess the scale.

tsc clean; full suite 402 passed.

---------

Co-authored-by: Miracle656 <iupacnumen2020@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Prometheus /metrics endpoint

2 participants